Skip to content

Add D256 to GQA-8 two-pass vector attention - #4477

Closed
wyanzhao wants to merge 2 commits into
ml-explore:mainfrom
wyanzhao:pr/sdpa-gqa-d256-rebased
Closed

Add D256 to GQA-8 two-pass vector attention#4477
wyanzhao wants to merge 2 commits into
ml-explore:mainfrom
wyanzhao:pr/sdpa-gqa-d256-rebased

Conversation

@wyanzhao

@wyanzhao wyanzhao commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

The GQA-8 two-pass kernel already shares K/V loads across query heads for D64 and D128. D256 can use the same kernel with two query heads per simdgroup: its float threadgroup arrays take 16,512 bytes, while four heads would exceed 32 KiB.

This adds that specialization for single-token decode with at least 8192 keys, matching query/value dimensions, no array mask and no sinks. It fits the D256, 8/1-head attention layers in CodeGemma 2B when using an ordinary floating-point KV cache. Quantized KV cache uses a separate path.

The six measured cells below are faster on M5 Max. Other Metal GPUs have not been measured. Two three-pair CodeGemma whole-model screens were inconclusive and do not support a throughput claim. Tests cover all three dtypes, odd key lengths, batch 2 and sliced KV. From python/tests, MLX_ENABLE_TF32=0 python -m unittest -v test_fast_sdpa passed (28 tests, 2 skipped); uvx pre-commit run --all-files passed.

Times are arm medians; ratios are paired geometric means of main/PR time, with 95% CIs (MLX_ENABLE_TF32=0). The table was measured at b04aea2a7 against 5778a97c0; merge commit 288906a50 was correctness-tested but not retimed.

dtype B, query/KV heads qL / kL Main / PR (µs) Paired ratio [95% CI]
float16 1, 16/2 1 / 8192 102.4 / 92.4 1.106 [1.099, 1.113]
float16 1, 16/2 1 / 32768 267.6 / 230.8 1.162 [1.158, 1.167]
bfloat16 1, 16/2 1 / 8192 97.5 / 87.7 1.109 [1.103, 1.114]
bfloat16 1, 16/2 1 / 32768 288.7 / 244.6 1.182 [1.174, 1.190]
float32 1, 16/2 1 / 8192 123.1 / 109.6 1.130 [1.120, 1.140]
float32 1, 16/2 1 / 32768 424.9 / 359.3 1.183 [1.172, 1.195]
Benchmark reproduction

Measured builds: main 5778a97c0, candidate b04aea2a7; Apple M5 Max, 128 GiB, macOS 27.0 (26A5425a). Build separate source checkouts with identical Release settings and Python bindings under each checkout's python/ directory. Save the script below as sdpa_microbench.py.

MLX_ENABLE_TF32=0 python sdpa_microbench.py --package /path/to/main/python --q 1 --k 8192
MLX_ENABLE_TF32=0 python sdpa_microbench.py --package /path/to/pr/python --q 1 --k 8192

Set --dtype, --q, --k, --hq, --hk and --batch for each row. The script reports seconds per call using a four-call dependent chain. Keep other GPU work idle; thermal-limit telemetry was unavailable during these measurements.

Use 30 fixed main/main calibration pairs followed by 30 main/PR pairs, alternating package order, with 60 seconds of preconditioning and 30 seconds between batches. The float32 cells used a separate session with five minutes of initial cooling.

Reject arm-median drift above 5% between session halves; allow one retry after 120 seconds of cooling with doubled preconditioning. The float32 k32768 A/B used that retry. Retain all pairs, including flagged outliers, in the paired log-ratio mean and Student-t 95% interval. Do not pool sessions; treat overlap with the calibration interval as unresolved. All six comparisons passed calibration and drift checks.

import argparse
import statistics
import sys
import time
from pathlib import Path

p = argparse.ArgumentParser()
p.add_argument('--package', required=True, help='Source build python/ directory')
p.add_argument('--dtype', default='float16')
p.add_argument('--q', type=int, required=True)
p.add_argument('--k', type=int, required=True)
p.add_argument('--batch', type=int, default=1)
p.add_argument('--hq', type=int, default=16)
p.add_argument('--hk', type=int, default=2)
p.add_argument('--causal', action='store_true')
a = p.parse_args()
a.package = str(Path(a.package).resolve())
sys.path.insert(0, a.package)
import mlx.core as mx

assert a.package in mx.__file__, mx.__file__
print("BUILD", mx.__file__)

mx.set_default_device(mx.gpu)
mx.random.seed(0)
dtype = getattr(mx, a.dtype)
q = mx.random.normal((a.batch, a.hq, a.q, 256)).astype(dtype)
k = mx.random.normal((a.batch, a.hk, a.k + 32, 256)).astype(dtype)[:, :, :a.k]
v = mx.random.normal((a.batch, a.hk, a.k + 32, 256)).astype(dtype)[:, :, :a.k]
mx.eval(q, k, v)

def chain():
    x = q
    for _ in range(4):
        x = mx.fast.scaled_dot_product_attention(
            x, k, v, scale=1 / 16, mask='causal' if a.causal else None
        )
    mx.eval(x)

for _ in range(8):
    chain()
mx.synchronize()
samples = []
for _ in range(5):
    start = time.perf_counter()
    for _ in range(64):
        chain()
    mx.synchronize()
    samples.append((time.perf_counter() - start) / (64 * 4))
print('RESULT', statistics.median(samples))
  • ☑️ I understand it is strictly prohibited to use AI to write PR description
  • AI usage disclosure: AI assistance was used in preparing this contribution. I am responsible for the contribution.

@wyanzhao
wyanzhao marked this pull request as ready for review September 8, 2026 20:00
@zcbenz
zcbenz requested a review from RohanGautam September 9, 2026 00:59

@RohanGautam RohanGautam left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for another PR!

As with your other one, I think you can include some end to end numbers, and I'll run it on some more machines to verify.

self.assertTrue(mx.allclose(ref, out, atol=1e-4, rtol=1e-4))

@unittest.skipUnless(mx.metal.is_available(), "Metal is not available")
def test_sdpa_vector_gqa_d256(self):

@RohanGautam RohanGautam Sep 12, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same concern i had in #4476 , this mainly tests numerics and not necessarily the changes you introduced. For your changes, good benchmarking should be sufficient. But let me know if you disagree!

@RohanGautam RohanGautam added the await response This pull request is waiting for response from the author. label Sep 12, 2026
@wyanzhao

Copy link
Copy Markdown
Contributor Author

Thanks for taking a look. I tested the model-level decode path with CodeGemma 2B at an 8K context. The kernel benchmark still improves, but the exact base/PR model rerun did not show a stable end-to-end gain: the three paired base/PR ratios were 0.9993x, 1.0093x, and 1.0042x (1.0042x median).

I haven't found a workload where the kernel improvement translates into a stable model-level gain, so I'm closing this PR.

@wyanzhao wyanzhao closed this Sep 13, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

await response This pull request is waiting for response from the author.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants